santree 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/dist/commands/dashboard.js +115 -95
- package/dist/commands/doctor.js +66 -1
- package/dist/commands/helpers/text-editor.d.ts +13 -0
- package/dist/commands/helpers/text-editor.js +118 -0
- package/dist/commands/worktree/create.d.ts +1 -0
- package/dist/commands/worktree/create.js +30 -38
- package/dist/lib/ai.js +11 -8
- package/dist/lib/dashboard/MultilineTextArea.js +225 -82
- package/dist/lib/dashboard/Overlays.js +1 -1
- package/dist/lib/dashboard/data.js +5 -5
- package/dist/lib/dashboard/external-editor.d.ts +12 -0
- package/dist/lib/dashboard/external-editor.js +74 -0
- package/dist/lib/git.d.ts +6 -4
- package/dist/lib/git.js +8 -33
- package/dist/lib/multiplexer/cmux.d.ts +2 -0
- package/dist/lib/multiplexer/cmux.js +97 -0
- package/dist/lib/multiplexer/index.d.ts +4 -0
- package/dist/lib/multiplexer/index.js +20 -0
- package/dist/lib/multiplexer/none.d.ts +2 -0
- package/dist/lib/multiplexer/none.js +22 -0
- package/dist/lib/multiplexer/tmux.d.ts +2 -0
- package/dist/lib/multiplexer/tmux.js +82 -0
- package/dist/lib/multiplexer/types.d.ts +23 -0
- package/dist/lib/multiplexer/types.js +3 -0
- package/dist/lib/session-signal.js +5 -8
- package/package.json +1 -1
- package/shell/init.zsh.njk +45 -15
package/README.md
CHANGED
|
@@ -317,6 +317,15 @@ echo "$(date): $SANTREE_TICKET_ID waiting — $SANTREE_MESSAGE" >> /tmp/santree-
|
|
|
317
317
|
|
|
318
318
|
Make it executable: `chmod +x .santree/hooks/on-waiting.sh`
|
|
319
319
|
|
|
320
|
+
### Environment Variables
|
|
321
|
+
|
|
322
|
+
| Variable | Effect |
|
|
323
|
+
|---|---|
|
|
324
|
+
| `SANTREE_EDITOR` | Editor used by `e` (open in editor) actions in the dashboard. Defaults to `code`. Examples: `cursor`, `zed`, `code`, `nvim`. |
|
|
325
|
+
| `SANTREE_MULTIPLEXER` | Terminal multiplexer used by the dashboard and `worktree create --window`. One of `tmux`, `cmux`, `none`. If unset, auto-detects from `$TMUX` / `$CMUX_SURFACE_ID`. cmux is macOS-only and limited by [manaflow-ai/cmux#1472](https://github.com/manaflow-ai/cmux/issues/1472). |
|
|
326
|
+
|
|
327
|
+
Santree always launches Claude with `--permission-mode auto` (Claude Code's auto mode), or `plan` when invoked in plan mode. Worktree-scoped automation is the default — there is no opt-in flag.
|
|
328
|
+
|
|
320
329
|
---
|
|
321
330
|
|
|
322
331
|
## Command Options
|
|
@@ -13,6 +13,7 @@ import { findMainRepoRoot, createWorktree, getDefaultBranch, getBaseBranch, hasI
|
|
|
13
13
|
import { run, spawnAsync } from "../lib/exec.js";
|
|
14
14
|
import { resolveAgentBinary } from "../lib/ai.js";
|
|
15
15
|
import { extractTicketId } from "../lib/git.js";
|
|
16
|
+
import { getMultiplexer } from "../lib/multiplexer/index.js";
|
|
16
17
|
import { getPRTemplate } from "../lib/github.js";
|
|
17
18
|
import { renderPrompt, renderDiff, renderTicket } from "../lib/prompts.js";
|
|
18
19
|
import { getTicketContent } from "../lib/linear.js";
|
|
@@ -39,9 +40,6 @@ const CLAUDE_VERSION = (() => {
|
|
|
39
40
|
}
|
|
40
41
|
})();
|
|
41
42
|
// ── Helpers ───────────────────────────────────────────────────────────
|
|
42
|
-
function isInTmux() {
|
|
43
|
-
return !!process.env.TMUX;
|
|
44
|
-
}
|
|
45
43
|
function slugify(title) {
|
|
46
44
|
return title
|
|
47
45
|
.toLowerCase()
|
|
@@ -137,12 +135,7 @@ function ensureAltScreen() {
|
|
|
137
135
|
if (altScreenEntered)
|
|
138
136
|
return;
|
|
139
137
|
altScreenEntered = true;
|
|
140
|
-
|
|
141
|
-
try {
|
|
142
|
-
execSync('tmux rename-window "santree"', { stdio: "ignore" });
|
|
143
|
-
}
|
|
144
|
-
catch { }
|
|
145
|
-
}
|
|
138
|
+
getMultiplexer().renameWindow("", "santree");
|
|
146
139
|
process.stdout.write("\x1b[?1049h"); // Enter alternate screen buffer
|
|
147
140
|
process.stdout.write("\x1b[?25l"); // Hide cursor
|
|
148
141
|
}
|
|
@@ -385,66 +378,81 @@ export default function Dashboard() {
|
|
|
385
378
|
};
|
|
386
379
|
}, [state.overlay, state.contextInputPhase, state.prCreatePhase]);
|
|
387
380
|
// ── Actions ───────────────────────────────────────────────────────
|
|
388
|
-
const launchWorkInTmux = useCallback((di, mode, worktreePath, contextFile) => {
|
|
381
|
+
const launchWorkInTmux = useCallback(async (di, mode, worktreePath, contextFile) => {
|
|
389
382
|
const windowName = di.issue.identifier;
|
|
390
383
|
const sessionId = di.worktree?.sessionId;
|
|
391
384
|
const bin = resolveAgentBinary();
|
|
392
385
|
const resumeCmd = sessionId && bin ? `${bin} --resume ${sessionId}` : null;
|
|
393
386
|
const contextArg = contextFile ? ` --context-file "${contextFile}"` : "";
|
|
394
387
|
const workCmd = mode === "plan" ? `st worktree work --plan${contextArg}` : `st worktree work${contextArg}`;
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
388
|
+
const cmd = resumeCmd ?? workCmd;
|
|
389
|
+
const mux = getMultiplexer();
|
|
390
|
+
const selected = await mux.selectWindow(windowName);
|
|
391
|
+
if (selected.ok) {
|
|
392
|
+
const sent = mux.sendCommand(windowName, cmd);
|
|
393
|
+
if (sent.ok) {
|
|
394
|
+
dispatch({
|
|
395
|
+
type: "SET_ACTION_MESSAGE",
|
|
396
|
+
message: resumeCmd
|
|
397
|
+
? `Resumed session in: ${windowName}`
|
|
398
|
+
: `Launched ${mode} in: ${windowName}`,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
dispatch({
|
|
403
|
+
type: "SET_ACTION_MESSAGE",
|
|
404
|
+
message: `Focused ${windowName} — run \`${cmd}\` manually (${sent.reason})`,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
406
407
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
const cmd = resumeCmd ?? workCmd;
|
|
415
|
-
execSync(`tmux send-keys -t "${windowName}" "${cmd}" Enter`, { stdio: "ignore" });
|
|
408
|
+
else {
|
|
409
|
+
const created = await mux.createWindow({
|
|
410
|
+
name: windowName,
|
|
411
|
+
cwd: worktreePath,
|
|
412
|
+
command: cmd,
|
|
413
|
+
});
|
|
414
|
+
if (created.ok) {
|
|
416
415
|
dispatch({
|
|
417
416
|
type: "SET_ACTION_MESSAGE",
|
|
418
417
|
message: resumeCmd
|
|
419
418
|
? `Resumed session in new window: ${windowName}`
|
|
420
|
-
: `Launched ${mode} in
|
|
419
|
+
: `Launched ${mode} in ${mux.kind} window: ${windowName}`,
|
|
421
420
|
});
|
|
422
421
|
}
|
|
423
|
-
|
|
424
|
-
dispatch({
|
|
422
|
+
else {
|
|
423
|
+
dispatch({
|
|
424
|
+
type: "SET_ACTION_MESSAGE",
|
|
425
|
+
message: `Failed to create ${mux.kind} window${created.message ? `: ${created.message}` : ""}`,
|
|
426
|
+
});
|
|
425
427
|
}
|
|
426
428
|
}
|
|
427
429
|
// Delayed refresh to pick up session ID created by `st worktree work`
|
|
428
430
|
setTimeout(() => refresh(), 3000);
|
|
429
431
|
}, [refresh]);
|
|
430
|
-
const launchAfterCreation = useCallback((mode, worktreePath, ticketId, contextFile) => {
|
|
431
|
-
|
|
432
|
+
const launchAfterCreation = useCallback(async (mode, worktreePath, ticketId, contextFile) => {
|
|
433
|
+
const mux = getMultiplexer();
|
|
434
|
+
if (mux.isActive()) {
|
|
432
435
|
const windowName = ticketId;
|
|
433
436
|
const contextArg = contextFile ? ` --context-file "${contextFile}"` : "";
|
|
434
437
|
const workCmd = mode === "plan"
|
|
435
438
|
? `st worktree work --plan${contextArg}`
|
|
436
439
|
: `st worktree work${contextArg}`;
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
440
|
+
const created = await mux.createWindow({
|
|
441
|
+
name: windowName,
|
|
442
|
+
cwd: worktreePath,
|
|
443
|
+
command: workCmd,
|
|
444
|
+
});
|
|
445
|
+
if (created.ok) {
|
|
441
446
|
dispatch({
|
|
442
447
|
type: "SET_ACTION_MESSAGE",
|
|
443
448
|
message: `Created worktree + launched ${mode} in: ${windowName}`,
|
|
444
449
|
});
|
|
445
450
|
}
|
|
446
|
-
|
|
447
|
-
dispatch({
|
|
451
|
+
else {
|
|
452
|
+
dispatch({
|
|
453
|
+
type: "SET_ACTION_MESSAGE",
|
|
454
|
+
message: `Worktree created, but ${mux.kind} failed${created.message ? `: ${created.message}` : ""}`,
|
|
455
|
+
});
|
|
448
456
|
}
|
|
449
457
|
setTimeout(() => refresh(), 3000);
|
|
450
458
|
}
|
|
@@ -590,8 +598,8 @@ export default function Dashboard() {
|
|
|
590
598
|
const contextFile = writeContextFile(customContext);
|
|
591
599
|
if (di.worktree) {
|
|
592
600
|
// Worktree exists — launch work
|
|
593
|
-
if (
|
|
594
|
-
launchWorkInTmux(di, mode, di.worktree.path, contextFile);
|
|
601
|
+
if (getMultiplexer().isActive()) {
|
|
602
|
+
void launchWorkInTmux(di, mode, di.worktree.path, contextFile);
|
|
595
603
|
}
|
|
596
604
|
else {
|
|
597
605
|
leaveAltScreen();
|
|
@@ -1225,7 +1233,7 @@ export default function Dashboard() {
|
|
|
1225
1233
|
dispatch({ type: "SET_ACTION_MESSAGE", message: `Opened in ${editor}` });
|
|
1226
1234
|
return;
|
|
1227
1235
|
}
|
|
1228
|
-
// AI Review in
|
|
1236
|
+
// AI Review in multiplexer
|
|
1229
1237
|
if (input === "r") {
|
|
1230
1238
|
if (!ri.worktree) {
|
|
1231
1239
|
dispatch({
|
|
@@ -1234,21 +1242,23 @@ export default function Dashboard() {
|
|
|
1234
1242
|
});
|
|
1235
1243
|
return;
|
|
1236
1244
|
}
|
|
1237
|
-
|
|
1245
|
+
const mux = getMultiplexer();
|
|
1246
|
+
if (mux.isActive()) {
|
|
1238
1247
|
const windowName = `review-${extractTicketId(ri.branch ?? "") ?? ri.pr.number}`;
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1248
|
+
const cwd = ri.worktree.path;
|
|
1249
|
+
void (async () => {
|
|
1250
|
+
const created = await mux.createWindow({
|
|
1251
|
+
name: windowName,
|
|
1252
|
+
cwd,
|
|
1253
|
+
command: "st pr review",
|
|
1242
1254
|
});
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1255
|
+
dispatch({
|
|
1256
|
+
type: "SET_ACTION_MESSAGE",
|
|
1257
|
+
message: created.ok
|
|
1258
|
+
? `Launched AI review in ${mux.kind}`
|
|
1259
|
+
: `Failed to launch review${created.message ? `: ${created.message}` : ""}`,
|
|
1246
1260
|
});
|
|
1247
|
-
|
|
1248
|
-
}
|
|
1249
|
-
catch {
|
|
1250
|
-
dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to launch review" });
|
|
1251
|
-
}
|
|
1261
|
+
})();
|
|
1252
1262
|
}
|
|
1253
1263
|
else {
|
|
1254
1264
|
leaveAltScreen();
|
|
@@ -1330,30 +1340,30 @@ export default function Dashboard() {
|
|
|
1330
1340
|
dispatch({ type: "SET_ACTION_MESSAGE", message: "No worktree to switch to" });
|
|
1331
1341
|
return;
|
|
1332
1342
|
}
|
|
1333
|
-
|
|
1343
|
+
const mux = getMultiplexer();
|
|
1344
|
+
if (mux.isActive()) {
|
|
1334
1345
|
const windowName = di.issue.identifier;
|
|
1335
1346
|
const sessionId = di.worktree.sessionId;
|
|
1336
1347
|
const bin = resolveAgentBinary();
|
|
1337
1348
|
const resumeCmd = sessionId && bin ? `${bin} --resume ${sessionId}` : null;
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1349
|
+
const worktreePath = di.worktree.path;
|
|
1350
|
+
void (async () => {
|
|
1351
|
+
const selected = await mux.selectWindow(windowName);
|
|
1352
|
+
if (selected.ok)
|
|
1353
|
+
return;
|
|
1354
|
+
const cmd = resumeCmd ?? "st worktree work";
|
|
1355
|
+
const created = await mux.createWindow({
|
|
1356
|
+
name: windowName,
|
|
1357
|
+
cwd: worktreePath,
|
|
1358
|
+
command: cmd,
|
|
1359
|
+
});
|
|
1360
|
+
if (!created.ok) {
|
|
1361
|
+
dispatch({
|
|
1362
|
+
type: "SET_ACTION_MESSAGE",
|
|
1363
|
+
message: `Failed to switch ${mux.kind} window${created.message ? `: ${created.message}` : ""}`,
|
|
1351
1364
|
});
|
|
1352
1365
|
}
|
|
1353
|
-
|
|
1354
|
-
dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to switch tmux window" });
|
|
1355
|
-
}
|
|
1356
|
-
}
|
|
1366
|
+
})();
|
|
1357
1367
|
}
|
|
1358
1368
|
else {
|
|
1359
1369
|
leaveAltScreen();
|
|
@@ -1408,18 +1418,23 @@ export default function Dashboard() {
|
|
|
1408
1418
|
dispatch({ type: "SET_ACTION_MESSAGE", message: "No PR to review" });
|
|
1409
1419
|
return;
|
|
1410
1420
|
}
|
|
1411
|
-
|
|
1421
|
+
const mux = getMultiplexer();
|
|
1422
|
+
if (mux.isActive()) {
|
|
1412
1423
|
const windowName = `review-${di.issue.identifier}`;
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1424
|
+
const cwd = di.worktree.path;
|
|
1425
|
+
void (async () => {
|
|
1426
|
+
const created = await mux.createWindow({
|
|
1427
|
+
name: windowName,
|
|
1428
|
+
cwd,
|
|
1429
|
+
command: "st pr review",
|
|
1416
1430
|
});
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1431
|
+
dispatch({
|
|
1432
|
+
type: "SET_ACTION_MESSAGE",
|
|
1433
|
+
message: created.ok
|
|
1434
|
+
? `Launched review in ${mux.kind}`
|
|
1435
|
+
: `Failed to launch review${created.message ? `: ${created.message}` : ""}`,
|
|
1436
|
+
});
|
|
1437
|
+
})();
|
|
1423
1438
|
}
|
|
1424
1439
|
else {
|
|
1425
1440
|
leaveAltScreen();
|
|
@@ -1467,18 +1482,23 @@ export default function Dashboard() {
|
|
|
1467
1482
|
dispatch({ type: "SET_ACTION_MESSAGE", message: "No PR to fix" });
|
|
1468
1483
|
return;
|
|
1469
1484
|
}
|
|
1470
|
-
|
|
1485
|
+
const mux = getMultiplexer();
|
|
1486
|
+
if (mux.isActive()) {
|
|
1471
1487
|
const windowName = `fix-${di.issue.identifier}`;
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1488
|
+
const cwd = di.worktree.path;
|
|
1489
|
+
void (async () => {
|
|
1490
|
+
const created = await mux.createWindow({
|
|
1491
|
+
name: windowName,
|
|
1492
|
+
cwd,
|
|
1493
|
+
command: "st pr fix",
|
|
1475
1494
|
});
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1495
|
+
dispatch({
|
|
1496
|
+
type: "SET_ACTION_MESSAGE",
|
|
1497
|
+
message: created.ok
|
|
1498
|
+
? `Launched PR fix in ${mux.kind}`
|
|
1499
|
+
: `Failed to launch PR fix${created.message ? `: ${created.message}` : ""}`,
|
|
1500
|
+
});
|
|
1501
|
+
})();
|
|
1482
1502
|
}
|
|
1483
1503
|
else {
|
|
1484
1504
|
leaveAltScreen();
|
|
@@ -1510,7 +1530,7 @@ export default function Dashboard() {
|
|
|
1510
1530
|
}
|
|
1511
1531
|
const selectedIssue = state.flatIssues[state.selectedIndex] ?? null;
|
|
1512
1532
|
const selectedReview = state.flatReviews[state.reviewSelectedIndex] ?? null;
|
|
1513
|
-
return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Santree Dashboard" }), _jsxs(Text, { dimColor: true, children: [" ", "v", version] }), CLAUDE_VERSION ? (_jsxs(Text, { dimColor: true, children: [" ", "claude ", CLAUDE_VERSION] })) : null, _jsx(Text, { dimColor: true, children: state.refreshing ? " refreshing..." : "" }), state.actionMessage && (_jsxs(Text, { color: "yellow", children: [" ", state.actionMessage] }))] }), _jsxs(Box, { children: [_jsxs(Text, { bold: state.activeTab === "issues", color: state.activeTab === "issues" ? "cyan" : undefined, dimColor: state.activeTab !== "issues", children: [state.activeTab === "issues" ? "\u25b8 " : " ", "1 Issues (", state.flatIssues.length, ")"] }), _jsx(Text, { children: " " }), _jsxs(Text, { bold: state.activeTab === "reviews", color: state.activeTab === "reviews" ? "cyan" : undefined, dimColor: state.activeTab !== "reviews", children: [state.activeTab === "reviews" ? "\u25b8 " : " ", "2 Reviews (", state.flatReviews.length, ")"] })] }), state.overlay === "mode-select" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Select mode:" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "p" }), " Plan"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "i" }), " Implement"] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }) })) : state.overlay === "context-input" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", paddingX: 2, width: Math.min(columns - 8, 100), children: [_jsxs(Text, { bold: true, color: "cyan", children: ["Extra context for ", state.contextInputMode] }), _jsx(Text, { dimColor: true, children: "Optional \u2014 appended to the prompt before launching Claude" }), _jsx(Text, { children: " " }), state.contextInputPhase === "editing" ? (_jsxs(_Fragment, { children: [_jsx(MultilineTextArea, { value: state.contextInputValue, onChange: (v) => dispatch({ type: "CONTEXT_INPUT_CHANGE", value: v }), onSubmit: () => dispatch({ type: "CONTEXT_INPUT_REVIEW" }), onCancel: () => dispatch({ type: "CONTEXT_INPUT_DONE" }), width: Math.min(columns - 8, 100), height: 10, placeholder: "Type or paste extra context\u2026" }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "
|
|
1533
|
+
return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Santree Dashboard" }), _jsxs(Text, { dimColor: true, children: [" ", "v", version] }), CLAUDE_VERSION ? (_jsxs(Text, { dimColor: true, children: [" ", "claude ", CLAUDE_VERSION] })) : null, _jsx(Text, { dimColor: true, children: state.refreshing ? " refreshing..." : "" }), state.actionMessage && (_jsxs(Text, { color: "yellow", children: [" ", state.actionMessage] }))] }), _jsxs(Box, { children: [_jsxs(Text, { bold: state.activeTab === "issues", color: state.activeTab === "issues" ? "cyan" : undefined, dimColor: state.activeTab !== "issues", children: [state.activeTab === "issues" ? "\u25b8 " : " ", "1 Issues (", state.flatIssues.length, ")"] }), _jsx(Text, { children: " " }), _jsxs(Text, { bold: state.activeTab === "reviews", color: state.activeTab === "reviews" ? "cyan" : undefined, dimColor: state.activeTab !== "reviews", children: [state.activeTab === "reviews" ? "\u25b8 " : " ", "2 Reviews (", state.flatReviews.length, ")"] })] }), state.overlay === "mode-select" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Select mode:" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "p" }), " Plan"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "i" }), " Implement"] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }) })) : state.overlay === "context-input" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", paddingX: 2, width: Math.min(columns - 8, 100), children: [_jsxs(Text, { bold: true, color: "cyan", children: ["Extra context for ", state.contextInputMode] }), _jsx(Text, { dimColor: true, children: "Optional \u2014 appended to the prompt before launching Claude" }), _jsx(Text, { children: " " }), state.contextInputPhase === "editing" ? (_jsxs(_Fragment, { children: [_jsx(MultilineTextArea, { value: state.contextInputValue, onChange: (v) => dispatch({ type: "CONTEXT_INPUT_CHANGE", value: v }), onSubmit: () => dispatch({ type: "CONTEXT_INPUT_REVIEW" }), onCancel: () => dispatch({ type: "CONTEXT_INPUT_DONE" }), width: Math.min(columns - 8, 100), height: 10, placeholder: "Type or paste extra context\u2026" }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "Ctrl+D" }), " send · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+O" }), " editor · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+C" }), " cancel"] })] })) : (_jsxs(_Fragment, { children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, minHeight: 6, children: [(state.contextInputValue || "(no extra context)")
|
|
1514
1534
|
.split("\n")
|
|
1515
1535
|
.slice(0, 12)
|
|
1516
1536
|
.map((line, i) => (_jsx(Text, { children: line || " " }, i))), state.contextInputValue.split("\n").length > 12 && (_jsxs(Text, { dimColor: true, children: ["\u2026+", state.contextInputValue.split("\n").length - 12, " more lines"] }))] }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Anything else to add?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " / ", _jsx(Text, { color: "green", bold: true, children: "Enter" }), " launch ", _jsx(Text, { color: "yellow", bold: true, children: "n" }), " / ", _jsx(Text, { color: "yellow", bold: true, children: "e" }), " keep editing ", _jsx(Text, { color: "red", bold: true, children: "ESC" }), " cancel"] })] }))] }) })) : state.overlay === "base-select" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Select base branch:" }), _jsx(Text, { children: " " }), state.baseSelectOptions.map((branch, idx) => {
|
package/dist/commands/doctor.js
CHANGED
|
@@ -11,6 +11,7 @@ const require = createRequire(import.meta.url);
|
|
|
11
11
|
const { version } = require("../../package.json");
|
|
12
12
|
import { findMainRepoRoot, getSantreeDir, getInitScriptPath } from "../lib/git.js";
|
|
13
13
|
import { getAuthStatus, getValidTokens } from "../lib/linear.js";
|
|
14
|
+
import { getMultiplexer } from "../lib/multiplexer/index.js";
|
|
14
15
|
const execAsync = promisify(exec);
|
|
15
16
|
export const description = "Check system requirements and integrations";
|
|
16
17
|
/**
|
|
@@ -55,6 +56,70 @@ async function checkTool(name, description, required, versionCommand, hint) {
|
|
|
55
56
|
path,
|
|
56
57
|
};
|
|
57
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Reports the active multiplexer (tmux/cmux/none) and verifies the underlying
|
|
61
|
+
* binary is reachable. Surfaces a hint when the configured multiplexer can't run.
|
|
62
|
+
*/
|
|
63
|
+
async function checkMultiplexer() {
|
|
64
|
+
const mux = getMultiplexer();
|
|
65
|
+
const explicit = process.env["SANTREE_MULTIPLEXER"]?.toLowerCase();
|
|
66
|
+
const description = `Multiplexer (active: ${mux.kind}${explicit ? `, SANTREE_MULTIPLEXER=${explicit}` : ""})`;
|
|
67
|
+
if (mux.kind === "none") {
|
|
68
|
+
return {
|
|
69
|
+
name: "multiplexer",
|
|
70
|
+
description,
|
|
71
|
+
required: false,
|
|
72
|
+
installed: false,
|
|
73
|
+
hint: "No multiplexer active. Set SANTREE_MULTIPLEXER=tmux (or cmux) and run inside one. Install: brew install tmux",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (mux.kind === "tmux") {
|
|
77
|
+
const path = await getPath("tmux");
|
|
78
|
+
if (!path) {
|
|
79
|
+
return {
|
|
80
|
+
name: "tmux",
|
|
81
|
+
description,
|
|
82
|
+
required: false,
|
|
83
|
+
installed: false,
|
|
84
|
+
hint: "Install: brew install tmux",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const version = await tryExec("tmux -V");
|
|
88
|
+
return {
|
|
89
|
+
name: "tmux",
|
|
90
|
+
description,
|
|
91
|
+
required: false,
|
|
92
|
+
installed: true,
|
|
93
|
+
version: version || "unknown",
|
|
94
|
+
path,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
// cmux
|
|
98
|
+
const path = await getPath("cmux");
|
|
99
|
+
if (!path) {
|
|
100
|
+
return {
|
|
101
|
+
name: "cmux",
|
|
102
|
+
description,
|
|
103
|
+
required: false,
|
|
104
|
+
installed: false,
|
|
105
|
+
hint: "Install cmux.app from https://cmux.com or set SANTREE_MULTIPLEXER=tmux. cmux is macOS-only.",
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const version = await tryExec("cmux --version 2>/dev/null");
|
|
109
|
+
const ping = await tryExec("cmux ping 2>/dev/null");
|
|
110
|
+
const hint = !ping
|
|
111
|
+
? "cmux app not reachable — open cmux.app or set SANTREE_MULTIPLEXER=tmux. NOTE: cmux #1472 — programmatic workspaces may have dead PTYs (https://github.com/manaflow-ai/cmux/issues/1472)."
|
|
112
|
+
: "NOTE: cmux #1472 — programmatic workspaces may have dead PTYs (https://github.com/manaflow-ai/cmux/issues/1472).";
|
|
113
|
+
return {
|
|
114
|
+
name: "cmux",
|
|
115
|
+
description,
|
|
116
|
+
required: false,
|
|
117
|
+
installed: !!ping,
|
|
118
|
+
version: version || "unknown",
|
|
119
|
+
path,
|
|
120
|
+
hint,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
58
123
|
/**
|
|
59
124
|
* Checks GitHub CLI auth status.
|
|
60
125
|
* Uses `gh api user` which works across all gh versions.
|
|
@@ -399,7 +464,7 @@ export default function Doctor() {
|
|
|
399
464
|
const results = await Promise.all([
|
|
400
465
|
checkTool("git", "Version control", true, "git --version | head -1", "Install: brew install git"),
|
|
401
466
|
checkGhAuth(),
|
|
402
|
-
|
|
467
|
+
checkMultiplexer(),
|
|
403
468
|
checkTool("claude", "Claude Code CLI", true, "claude --version 2>/dev/null | head -1", "Install: npm install -g @anthropic-ai/claude-code"),
|
|
404
469
|
]);
|
|
405
470
|
// Check for either code or cursor (only need one)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from "zod/v4";
|
|
2
|
+
export declare const description = "Open $EDITOR on a temp file, then print the path on stdout (compose with --context-file).";
|
|
3
|
+
export declare const options: z.ZodObject<{
|
|
4
|
+
initial: z.ZodOptional<z.ZodString>;
|
|
5
|
+
from: z.ZodOptional<z.ZodString>;
|
|
6
|
+
ext: z.ZodDefault<z.ZodString>;
|
|
7
|
+
editor: z.ZodOptional<z.ZodString>;
|
|
8
|
+
}, z.core.$strip>;
|
|
9
|
+
type Props = {
|
|
10
|
+
options: z.infer<typeof options>;
|
|
11
|
+
};
|
|
12
|
+
export default function TextEditor({ options: opts }: Props): null;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { useApp } from "ink";
|
|
3
|
+
import { option } from "pastel";
|
|
4
|
+
import { z } from "zod/v4";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
export const description = "Open $EDITOR on a temp file, then print the path on stdout (compose with --context-file).";
|
|
10
|
+
export const options = z.object({
|
|
11
|
+
initial: z
|
|
12
|
+
.string()
|
|
13
|
+
.optional()
|
|
14
|
+
.describe(option({ description: "Pre-fill the editor buffer with this text" })),
|
|
15
|
+
from: z
|
|
16
|
+
.string()
|
|
17
|
+
.optional()
|
|
18
|
+
.describe(option({ description: "Pre-fill the editor buffer with the contents of this file" })),
|
|
19
|
+
ext: z
|
|
20
|
+
.string()
|
|
21
|
+
.default("md")
|
|
22
|
+
.describe(option({ description: "Temp file extension (default: md)" })),
|
|
23
|
+
editor: z
|
|
24
|
+
.string()
|
|
25
|
+
.optional()
|
|
26
|
+
.describe(option({ description: "Override the editor command (default: $VISUAL || $EDITOR || vim)" })),
|
|
27
|
+
});
|
|
28
|
+
function resolveEditor(override) {
|
|
29
|
+
const raw = override ?? process.env["VISUAL"] ?? process.env["EDITOR"] ?? "vim";
|
|
30
|
+
const parts = raw.split(/\s+/).filter(Boolean);
|
|
31
|
+
const cmd = parts[0] ?? "vim";
|
|
32
|
+
return { cmd, args: parts.slice(1) };
|
|
33
|
+
}
|
|
34
|
+
// Render null and write all UI feedback to stderr so stdout stays clean for
|
|
35
|
+
// shell capture: `file=$(st helpers text-editor) && st worktree work --context-file "$file"`.
|
|
36
|
+
export default function TextEditor({ options: opts }) {
|
|
37
|
+
const { exit } = useApp();
|
|
38
|
+
const hasRun = useRef(false);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (hasRun.current)
|
|
41
|
+
return;
|
|
42
|
+
hasRun.current = true;
|
|
43
|
+
const ext = opts.ext.replace(/^\./, "");
|
|
44
|
+
const filePath = path.join(os.tmpdir(), `santree-edit-${Date.now()}.${ext}`);
|
|
45
|
+
const seed = (() => {
|
|
46
|
+
if (opts.from) {
|
|
47
|
+
try {
|
|
48
|
+
return fs.readFileSync(opts.from, "utf-8");
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return opts.initial ?? "";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return opts.initial ?? "";
|
|
55
|
+
})();
|
|
56
|
+
try {
|
|
57
|
+
fs.writeFileSync(filePath, seed);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
process.stderr.write(`Failed to create temp file: ${err.message}\n`);
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
exit();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// Ink put stdin in raw mode on mount; release it for the editor.
|
|
66
|
+
const wasRaw = process.stdin.isTTY ? process.stdin.isRaw : false;
|
|
67
|
+
if (process.stdin.isTTY && process.stdin.setRawMode) {
|
|
68
|
+
try {
|
|
69
|
+
process.stdin.setRawMode(false);
|
|
70
|
+
}
|
|
71
|
+
catch { }
|
|
72
|
+
}
|
|
73
|
+
const { cmd, args } = resolveEditor(opts.editor);
|
|
74
|
+
const result = spawnSync(cmd, [...args, filePath], { stdio: "inherit" });
|
|
75
|
+
if (process.stdin.isTTY && process.stdin.setRawMode) {
|
|
76
|
+
try {
|
|
77
|
+
process.stdin.setRawMode(wasRaw);
|
|
78
|
+
}
|
|
79
|
+
catch { }
|
|
80
|
+
}
|
|
81
|
+
if (result.error || result.status !== 0) {
|
|
82
|
+
process.stderr.write(result.error
|
|
83
|
+
? `Failed to launch editor '${cmd}': ${result.error.message}\n`
|
|
84
|
+
: `Editor '${cmd}' exited with status ${result.status}\n`);
|
|
85
|
+
try {
|
|
86
|
+
fs.unlinkSync(filePath);
|
|
87
|
+
}
|
|
88
|
+
catch { }
|
|
89
|
+
process.exitCode = 1;
|
|
90
|
+
exit();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
let content = "";
|
|
94
|
+
try {
|
|
95
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
process.stderr.write(`Failed to read temp file: ${err.message}\n`);
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
exit();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// Empty buffer => treat as cancel (matches `git commit` behavior)
|
|
104
|
+
if (content.trim().length === 0) {
|
|
105
|
+
try {
|
|
106
|
+
fs.unlinkSync(filePath);
|
|
107
|
+
}
|
|
108
|
+
catch { }
|
|
109
|
+
process.stderr.write("Cancelled (empty buffer)\n");
|
|
110
|
+
process.exitCode = 1;
|
|
111
|
+
exit();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
process.stdout.write(`${filePath}\n`);
|
|
115
|
+
exit();
|
|
116
|
+
}, [opts, exit]);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
@@ -5,6 +5,7 @@ export declare const options: z.ZodObject<{
|
|
|
5
5
|
work: z.ZodOptional<z.ZodBoolean>;
|
|
6
6
|
plan: z.ZodOptional<z.ZodBoolean>;
|
|
7
7
|
"no-pull": z.ZodOptional<z.ZodBoolean>;
|
|
8
|
+
window: z.ZodOptional<z.ZodBoolean>;
|
|
8
9
|
tmux: z.ZodOptional<z.ZodBoolean>;
|
|
9
10
|
name: z.ZodOptional<z.ZodString>;
|
|
10
11
|
}, z.core.$strip>;
|